Skip to content

Conversation

@hannesrudolph
Copy link
Collaborator

@hannesrudolph hannesrudolph commented Sep 18, 2025

Closes #8153

Before:

FocuSee.Project.2025-09-19.18-07-19.mp4

AFTER

FocuSee.Project.2025-09-19.18-12-22.mp4

Problem

  • Users occasionally saw a duplicate/overlaid task after pressing “Cancel” or after a mid-stream network failure—most visibly during “reasoning” streaming.
  • Root cause: two independent rehydration paths racing:
  • This race was easier to hit during reasoning because reasoning chunks are UI-only (persisted to clineMessages) and not added to apiConversationHistory mid-stream, so the Task catch reached rehydrate faster.

Changes

  1. Centralize rehydration in the Provider

    • Removed Task-side rehydrate in the streaming error/cancel catch; the Task now:
      • Persists an interruption marker first
      • Sets an explicit abortReason
      • Calls abortTask() and emits TaskAborted
      • Does not call createTaskWithHistoryItem
    • Provider listens for TaskAborted and rehydrates only for streaming failures (not user-cancels)
      Task.recursivelyMakeClineRequests()
      ClineProvider.onTaskAborted
  2. Prevent double-create by marking “abandoned” earlier on user-cancel

    • Set task.abortReason = "user_cancelled" and mark the current Task instance abandoned immediately after abortTask(), before waiting for the stream to drain
      ClineProvider.cancelTask()
    • This ensures the Task catch never tries to rehydrate.
  3. Provider-side cancel bookkeeping to mirror abortStream for user cancels

    • Because setting abandoned early can skip abortStream on user-cancel, the Provider now fills the same persistence gaps:
    • This preserves UI/API consistency and cancellation telemetry without reintroducing the race.
  4. Resume reconciliation for UI-only reasoning

    • On resuming from history, remove trailing reasoning-only UI rows that never entered apiConversationHistory (orphan partials)
      Task.resumeTaskFromHistory()
  5. Defensive safeguards to avoid rehydrate after rehydrate

Why this approach

  • Eliminates the duplicate/overlay by making the Provider the single source of rehydration truth.
  • Maintains a coherent timeline when cancelling mid-reasoning by explicitly persisting:
    • cancelReason into the last api_req_started row
    • an assistant “interrupted by user” entry into API history
  • Keeps normal success flows unchanged and produces a clean resume prompt consistently.

Behavioral impact

  • Cancel during reasoning: one rehydrate, no duplicates; cancelReason and interruption marker preserved.
  • Network failure mid-stream: Task emits TaskAborted with abortReason="streaming_failed"; Provider rehydrates once.
  • Cancel during text streaming: one rehydrate; no duplicates.
  • didFinishAbortingStream may not set on user-cancel (due to early abandoned), but Provider waits on other safe conditions and applies the same persistence updates Provider-side.

Key references


Important

Centralizes task rehydration in ClineProvider.ts to prevent duplicate rehydration during reasoning and updates localization for interruption messages.

  • Behavior:
    • Centralizes rehydration in ClineProvider.ts, removing task-side rehydration in Task.ts.
    • ClineProvider.ts now handles rehydration only for streaming failures, not user cancels.
    • Marks tasks as "abandoned" earlier on user-cancel to prevent double rehydration.
    • Updates UI/API consistency by appending interruption markers to apiConversationHistory.
  • Files:
    • Task.ts: Removes task-side rehydration logic, adds abortReason property.
    • ClineProvider.ts: Implements centralized rehydration logic, updates cancel bookkeeping.
    • Localization files: Adds interruption messages for user and API error interruptions.
  • Misc:
    • Ensures no rehydration occurs if the task instance has already changed.
    • Improves handling of reasoning-only UI rows during task resumption.

This description was created by Ellipsis for b1958e8. You can customize this summary. It will automatically update as commits are pushed.

…ze rehydrate in provider and preserve cancel metadata

- Remove Task-side rehydrate on stream error/cancel; provider owns rehydrate
- Set abandoned early on user cancel to prevent Task catch from rehydrating
- Provider writes cancelReason to last api_req_started and appends assistant interruption
- Trim orphan reasoning-only UI rows on resume
- Add defensive guards to avoid rehydrate-after-rehydrate
Copilot AI review requested due to automatic review settings September 18, 2025 23:55
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Sep 18, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Centralizes task rehydration logic to prevent duplicate rehydrations during reasoning interruptions and preserves cancellation metadata. The changes move rehydration control from the Task class to the ClineProvider to avoid race conditions.

  • Centralizes rehydration logic in ClineProvider with defensive guards against duplicate operations
  • Preserves cancel metadata in both UI and API message histories during user-initiated cancellations
  • Removes trailing reasoning-only UI messages during task rehydration to maintain consistency

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
ClineProvider.ts Centralizes rehydration in onTaskAborted handler with defensive instance checks, adds comprehensive cancel bookkeeping
Task.ts Adds abortReason tracking, removes local rehydration logic, cleans up trailing reasoning messages during rehydration

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

try {
// Only rehydrate on genuine streaming failures.
// User-initiated cancels are handled by cancelTask().
if ((instance as any).abortReason === "streaming_failed") {
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using type assertion (instance as any) bypasses type safety. Consider adding abortReason to the Task interface or creating a proper type guard to check for this property safely.

Copilot uses AI. Check for mistakes.
const parentTask = task.parentTask

// Mark this as a user-initiated cancellation so provider-only rehydration can occur
task.abortReason = "user_cancelled"
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct property assignment to abortReason suggests this property may not be part of the Task interface. Consider adding this property to the Task interface or using a setter method to ensure type safety.

Copilot uses AI. Check for mistakes.
// Update ui_messages: add cancelReason to last api_req_started
const messagesJson = await fs.readFile(uiMessagesFilePath, "utf8").catch(() => undefined)
if (messagesJson) {
const uiMsgs = JSON.parse(messagesJson) as ClineMessage[]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap the JSON.parse call on uiMessagesFilePath content in a try/catch to handle potential parsing errors gracefully.

This comment was generated because it violated a code review rule: irule_PTI8rjtnhwrWq6jS.


// Provider-side cancel bookkeeping to mirror abortStream effects for user_cancelled
try {
// Update ui_messages: add cancelReason to last api_req_started
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor the cancelTask method to extract UI and API history updating logic into smaller helper functions for improved readability and maintainability.

Copy link
Contributor

@roomote roomote bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this comprehensive fix to the duplicate rehydration issue! The centralization of rehydration logic in the Provider and the defensive guards against race conditions are solid architectural improvements.

Review Summary

I've reviewed the changes and found that the previously raised type safety concerns have been addressed - abortReason is now properly defined in the Task class.

Suggestions for Further Improvement

  1. Type assertion could be avoided - In ClineProvider.ts, the code uses (instance as any).abortReason even though abortReason is properly defined in the Task class. Consider using proper typing instead.

  2. Potential infinite loop in reasoning cleanup - The while loop in Task.ts that removes trailing reasoning messages could theoretically run indefinitely if messages are malformed. Consider adding a safety limit.

  3. Duplicate cancel bookkeeping logic - The cancel bookkeeping logic appears in both ClineProvider.cancelTask() and Task.abortStream(). Consider consolidating to reduce duplication.

  4. Extract magic strings as constants - Strings like "streaming_failed", "user_cancelled", and "[Response interrupted by user]" appear multiple times. Would be cleaner as named constants.

  5. Enhanced logging - Consider adding success logs when rehydration is performed, not just when it's skipped.

Overall, this is a well-thought-out solution that properly addresses the race condition issue. The changes effectively prevent duplicate rehydration and maintain proper state consistency.

@hannesrudolph hannesrudolph added the Issue/PR - Triage New issue. Needs quick review to confirm validity and assign labels. label Sep 19, 2025
- Mark original task instance abandoned instead of getCurrentTask()
- Add parse guards and centralize UI/API cancel bookkeeping helpers
- Replace any-cast with typed instance.abortReason
- Centralize interruption strings and reuse in Task/Provider
- Add final instanceId race-check before rehydrate
- Remove unused variable in cancelTask()
@hannesrudolph
Copy link
Collaborator Author

Summary of applied fixes (items 1–6)

Implemented

  1. Mark the original instance as abandoned (not getCurrentTask)
  1. Guard JSON parsing and centralize cancel bookkeeping
  1. Final race re-check before rehydrate
  • Added a second instanceId equality check immediately before rehydration to avoid duplicate rehydrate.
  • See ClineProvider.cancelTask()
  1. Remove any-cast for abortReason
  1. Centralize user-facing interruption strings (prep for i18n)
  1. Deduplicate cancel bookkeeping logic
  • Extracted shared logic into persistence helpers to keep a single source of truth.
  • See cancelBookkeeping.ts

Additional cleanup

Notes

  • The ‘reasoning cleanup’ hard-cap suggestion was not implemented (only items 1–6 were requested). The existing loop already terminates on first non-reasoning row and is linear in length; adding a cap remains possible if desired.

CI

  • Pushed changes to branch fix/reasoning-cancel-centralized-rehydrate. Checks are now pending.

@hannesrudolph hannesrudolph moved this from Triage to PR [Needs Prelim Review] in Roo Code Roadmap Sep 19, 2025
@hannesrudolph hannesrudolph added PR - Needs Preliminary Review and removed Issue/PR - Triage New issue. Needs quick review to confirm validity and assign labels. labels Sep 19, 2025
@hannesrudolph hannesrudolph force-pushed the fix/reasoning-cancel-centralized-rehydrate branch from d65a8f5 to 3bbf984 Compare September 19, 2025 01:09
*
* Note: These are plain phrases (no surrounding brackets). Call sites add any desired decoration.
*/
export const RESPONSE_INTERRUPTED_BY_USER = "Response interrupted by user"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These need to be internationalized


try {
const existing = uiMsgs[idx]?.text ? JSON.parse(uiMsgs[idx].text as string) : {}
uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: reason })
Copy link
Member

@daniel-lxs daniel-lxs Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check if there's already a reason here before overwriting?

@daniel-lxs daniel-lxs moved this from PR [Needs Prelim Review] to PR [Changes Requested] in Roo Code Roadmap Sep 19, 2025
…lized strings and remove unused message constants
…iting

- Add conditional check to preserve existing cancelReason values
- Only set cancelReason if it doesn't already exist
- Addresses review feedback from daniel-lxs
- Delete cancelBookkeeping.ts entirely
- Remove bookkeeping imports and calls from ClineProvider
- Simplify abortStream in Task.ts
- Keep core fixes: centralized rehydration, abortReason tracking, abandoned flag
- Let UI handle cancellation display without modifying persisted data
- Update log messages to reflect that we're no longer doing bookkeeping
- Rename variable from currentAfterBookkeeping to currentAfterCheck
- Make the code accurately describe what it's doing (race condition checks)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working lgtm This PR has been approved by a maintainer PR - Needs Review size:L This PR changes 100-499 lines, ignoring generated files.

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

[BUG] Cancel during response can blank conversation history (chat locks)

4 participants